Skip to content

feat(sdk/go/ai): support the Infron gateway - #874

Merged
AbirAbbas merged 3 commits into
Agent-Field:mainfrom
meridah7:feat/infron-provider
Aug 5, 2026
Merged

feat(sdk/go/ai): support the Infron gateway#874
AbirAbbas merged 3 commits into
Agent-Field:mainfrom
meridah7:feat/infron-provider

Conversation

@meridah7

@meridah7 meridah7 commented Aug 4, 2026

Copy link
Copy Markdown
Contributor

Summary

Adds the Infron gateway to sdk/go/ai, in the shape this package already uses to describe a gateway, so selecting it is an env-var change rather than a fork.

Infron is an OpenAI-compatible gateway serving the standard <provider>/<model> ids, so nothing about a model's identity changes when it runs there: infron/moonshotai/kimi-k2.6 routes the same model the bare id names. I followed the attribution/config helpers already in the package rather than introducing a second way to describe a provider.

Disclosure: I work on Infron. Everything below is checkable from the diff and the commands in the test plan.

File Change
sdk/go/ai/infron_attribution.go New. Mirrors the existing attribution helper. Infron accepts the same HTTP-Referer / X-Title pair, and the attribution env vars already configured in a deployment are honored as fallbacks, so a deployment that already declares itself as "AgentField AI" keeps that identity after switching gateways.
sdk/go/ai/config.go IsInfron(); DefaultConfig() reads INFRON_API_KEY and points at https://llm.onerouter.pro/v1.
sdk/go/ai/client.go Attaches attribution on both the sync and the streaming path, at the two call sites this package already uses for it.
sdk/go/ai/model_params.go Opts Infron into native usage accounting, and strips the routing-only infron/ prefix before the request goes out.
sdk/go/ai/response.go Top-level cost field + normalizeNativeCost() on Response and StreamChunk. See below.
docs/ENVIRONMENT_VARIABLES.md, sdk/go/ai/README.md The env vars and the prefix swap.
sdk/go/ai/infron_attribution_test.go New. 17 cases: detection, precedence, attribution fallback, prefix stripping, cost normalization.

The one real difference, handled rather than papered over

This package reads native cost from usage.cost. Infron reports it at the top level of the body, and of the final stream chunk:

{ "model": "deepseek/deepseek-v4-flash", "cost": 0.000002,
  "usage": { "prompt_tokens": 12, "completion_tokens": 9 } }   // no usage.cost

Parsed as-is that leaves Usage.Cost == nil, which recordLLMUsage reads as price unknown: the call is still recorded, but with a nil cost and an empty cost_source instead of "provider". Nothing errors, and it only shows up later as a hole in the cost data.

Response and StreamChunk now carry the top-level field, and normalizeNativeCost() folds it into Usage.Cost on parse, so every existing consumer keeps reading one place. An explicit usage.cost always wins; the fold only fills a gap.

The model prefix is stripped before the wire

infron/ is a routing marker for callers that select a gateway by model string, but the gateway serves the bare id, so leaving the prefix on returns No available providers for model infron/moonshotai/kimi-k2.6. stripInfronPrefix removes it in marshalRequest, mirroring the prefix handling this package already does on the media path. Only a copy of the Request is rewritten, so the caller's Request and config.Model are untouched and IsInfron() still reports the truth.

Worth flagging either way: the chat path does not strip the pre-existing gateway prefix today (only the media path does), so a model string carrying that prefix hits the same wall against its own gateway. I left that alone rather than change existing behavior inside a PR about a new provider, but happy to send it separately if you want the two symmetrical.

Backwards compatibility

A gateway key that was already honored before Infron existed keeps precedence. Adding INFRON_API_KEY to an existing environment never reroutes it; TestDefaultConfigExistingGatewayWinsOverInfron pins that. IsInfron() also does not match a bare shared model id (moonshotai/kimi-k2.6), only the explicit infron/ prefix or the Infron host, so gateways cannot be confused by model alone.

Type of change

  • New feature
  • Bug fix — one behavior change comes along for the ride: a gateway reporting cost at the top level now populates Usage.Cost instead of being dropped
  • Refactor / cleanup
  • Docs only
  • Tests only
  • CI / tooling
  • Breaking change

Test plan

Rebased on main at 4bc8ce7 and re-run today.

  • cd sdk/go && go test ./...agent, ai, client, did, inputs, types all ok
  • cd sdk/go && go test -race ./ai/... — clean
  • gofmt -l clean on every touched file; go vet ./ai/... clean
  • End-to-end against the live gateway, driving the real SDK — 29/29. Not hand-rolled HTTP: it builds ai.Config, calls ai.NewClient, and exercises Complete() and StreamComplete(). The attribution headers are asserted by putting a capturing reverse proxy in front of the real gateway, so what is checked is what actually went on the wire:
1. DefaultConfig picks up INFRON_API_KEY .................... 7/7
2. An existing gateway key still wins when both are set ..... 3/3
3. Attribution headers on the wire (proxy capture) .......... 7/7
     HTTP-Referer: https://agentfield.ai
     X-Title:      AgentField AI
     body:         {"model":"moonshotai/kimi-k2.6", ... "usage":{"include":true}}
4. LIVE sync call ........................................... 6/6
     text "hello", in=10 out=2, Usage.Cost $0.00000100
5. LIVE streaming call ...................................... 3/3
     final chunk Usage.Cost populated
6. Live calls on kimi-k2.6 / minimax-m2.5 / glm-5.2 ......... 3/3
  • A second live pass over the paths the first one does not touch — 31/31.
D1.  Agent cost tracker records cost_source=provider ........ 4/4
D2.  AGENTFIELD_INFRON_ATTRIBUTION=false suppresses headers . 3/3
D3.  AI_BASE_URL still overrides the Infron default ......... 3/3
D4.  Pre-existing gateway path untouched (mock upstream) .... 5/5
       attribution header still sent, usage.cost still read,
       its routing prefix still NOT stripped (unchanged)
D5.  Explicit usage.cost never clobbered by top-level cost ... 2/2
D6.  8 concurrent calls; caller config left unmutated ....... 3/3
D7.  Structured JSON output ................................. 3/3
D8.  Tool calling (kimi-k2.6, get_weather) .................. 5/5
D9.  Error paths (bad model, bad key) ....................... 2/2
D10. Prompt-cache token accounting (2008 cached tokens) ..... 1/1
  • Before/after proof that the cost gap was real. Same live call, same model, only the SDK revision differs:
main @ 4bc8ce7        Usage.Cost = <nil>          -> cost_source ""
feat/infron-provider  Usage.Cost = $0.00000100    -> cost_source "provider"

One pre-existing failure, unrelated to this PR: TestOpenCodeConcurrencyLimit_RealSubprocess in sdk/go/harness fails identically on a clean checkout of main on macOS — same test, same parse error, verified side by side before opening this. It shells out to date +%s%N, which BSD date does not support, so the test parses a literal N. It passes in CI on Linux. Happy to send that as a separate fix if useful.

Test coverage

  • I ran tests for the surface I changed locally (sdk-go).
  • New code paths are covered by tests in this PR (no bare additions).
  • No coverage-baseline.json change needed, and here is why:
main this PR
sdk/go/ai statement coverage 93.3% 93.2%

Measured back to back with -count=1; the number moves about 0.1 pp between runs on its own. That is against a max_surface_drop of 1.0 and a min_surface of 84.0, on the smaller of the two numbers feeding the sdk-go surface. Patch coverage on the non-test lines this PR adds is 92.7% (101/109 coverable added lines), against min_patch = 80.0; the new file itself is at 95.7%, IsInfron and the prefix strip at 100%.

Notes

  • Python SDK parity is the obvious follow-up (the litellm-side attribution module). Kept out to keep this reviewable. Happy to send it right after, or fold it in here if you would rather review once.
  • llm.onerouter.pro is deliberately not added to vouchedRewriteDomains. I probed both max_tokens and max_completion_tokens and they behaved identically, with neither demonstrably enforced, so I left the conservative legacy max_tokens path in place per the reasoning already in that comment. Easy to add if you have better information.
  • Companion PR on the SWE-AF side (Infron as an open_code provider + the INFRON_API_KEY auto-select path): feat: add Infron as an open_code gateway provider SWE-AF#126. The two are independent; either can land alone.

@meridah7
meridah7 requested review from a team and AbirAbbas as code owners August 4, 2026 21:48
@CLAassistant

CLAassistant commented Aug 4, 2026

Copy link
Copy Markdown

CLA assistant check
All committers have signed the CLA.

@meridah7 meridah7 changed the title feat(sdk/go/ai): support the Infron gateway alongside OpenRouter feat(sdk/go/ai): support the Infron gateway Aug 4, 2026
Infron is an OpenAI-compatible inference gateway that serves the standard
<provider>/<model> ids, so a model moves across by prefix alone:
infron/moonshotai/kimi-k2.6 routes the same model the bare id names.

Follows the provider shape already in this package rather than inventing
a new one:

- infron_attribution.go mirrors the existing attribution helper. Infron
  accepts the same HTTP-Referer / X-Title pair, and the attribution env
  vars already configured for the existing gateway are honored as
  fallbacks, so a deployment that already declares itself as
  'AgentField AI' keeps that identity after switching gateways.
- Config gains IsInfron(); DefaultConfig() reads INFRON_API_KEY and
  points at https://llm.onerouter.pro/v1.
- client.go attaches attribution on both the sync and streaming paths.
- marshalRequest opts Infron into native usage accounting and strips the
  routing-only 'infron/' model prefix before the request goes out
  (stripInfronPrefix, mirroring the prefix handling on the media path).
  The gateway serves the bare id, so leaving the prefix on returns 'No
  available providers for model infron/...'. Only a copy of the Request
  is rewritten; the caller's Request is untouched.

One real difference is handled rather than papered over: Infron returns
the native cost at the top level of the body and of the final stream
chunk, rather than nested under usage. Parsed naively that leaves
Usage.Cost nil, which the cost tracker reads as 'price unknown' -- usage
still recorded, but with no cost and an empty cost_source instead of
'provider'. Response/StreamChunk now carry the top-level field and
normalizeNativeCost folds it into Usage.Cost, so every existing consumer
keeps reading one place. An explicit usage.cost always wins.

A gateway key that was already honored before Infron existed keeps
precedence, so adding an Infron key never reroutes an existing
deployment.

llm.onerouter.pro is deliberately NOT added to vouchedRewriteDomains:
max_tokens and max_completion_tokens behaved identically in probing and
neither could be shown to be enforced, so the conservative legacy
max_tokens path stays, per the reasoning already in that comment.
@meridah7
meridah7 force-pushed the feat/infron-provider branch from 8574fd0 to 6323eaa Compare August 4, 2026 22:03
@santoshkumarradha

Copy link
Copy Markdown
Member

Thanks for putting this together. I did an initial pass on the diff, but I can’t take it to merge yet because the required repo checks never showed up on this PR. At the moment I only see , so branch protection is still blocking on missing checks like . Please rebase on current or otherwise retrigger the normal PR workflows, and I’ll do a final mergeability pass once the required checks are actually running.

@santoshkumarradha

Copy link
Copy Markdown
Member

Thanks for putting this together. I did an initial pass on the diff, but I can’t take it to merge yet because the required repo checks never showed up on this PR. At the moment I only see license/cla, so branch protection is still blocking on missing checks like coverage-summary. Please rebase on current main or otherwise retrigger the normal PR workflows, and I’ll do a final mergeability pass once the required checks are actually running.

@github-actions

github-actions Bot commented Aug 5, 2026

Copy link
Copy Markdown
Contributor

Performance

SDK Memory Δ Latency Δ Tests Status
Go 213 B -24% 0.58 µs -42%

✓ No regressions detected

@github-actions

github-actions Bot commented Aug 5, 2026

Copy link
Copy Markdown
Contributor

📊 Coverage gate

Thresholds from .coverage-gate.toml: per-surface ≥ 84%, aggregate ≥ 85%, max per-surface regression ≤ 1.0 pp, max aggregate regression ≤ 0.50 pp.

Surface Current Baseline Δ
control-plane 87.10% 87.40% ↓ -0.30 pp 🟡
sdk-go 92.70% 92.00% ↑ +0.70 pp 🟢
sdk-python 93.82% 93.73% ↑ +0.09 pp 🟢
sdk-typescript 91.17% 90.42% ↑ +0.75 pp 🟢
web-ui 84.76% 84.79% ↓ -0.03 pp 🟡
aggregate 85.62% 85.75% ↓ -0.13 pp 🟡

✅ Gate passed

No surface regressed past the allowed threshold and the aggregate stayed above the floor.

@github-actions

github-actions Bot commented Aug 5, 2026

Copy link
Copy Markdown
Contributor

📐 Patch coverage gate

Threshold: 80% on lines this PR touches vs origin/main (from .coverage-gate.toml:thresholds.min_patch).

Surface Touched lines Patch coverage Status
control-plane 0 ➖ no changes
sdk-go 112 92.00%
sdk-python 0 ➖ no changes
sdk-typescript 0 ➖ no changes
web-ui 0 ➖ no changes

✅ Patch gate passed

Every surface whose lines were touched by this PR has patch coverage at or above the threshold.

DefaultConfig applied the Infron block unconditionally, so an environment
with OPENAI_API_KEY set and INFRON_API_KEY added resolved to the Infron key
and base URL. That contradicts the guarantee stated in DefaultConfig's own
doc comment and in ENVIRONMENT_VARIABLES.md, and it matters because spawned
agent processes inherit the parent environment -- one exported INFRON_API_KEY
would move every Go agent's traffic and credential to a different gateway.

The existing precedence test cleared OPENAI_API_KEY on its first line, so it
only exercised the OpenRouter branch and the gap passed CI green. Adds the
regression test for the OpenAI case plus one pinning that Infron still applies
when it is the only gateway key set, and names the OpenRouter attribution
fallback vars in the docs so operators can audit what feeds the gateway.

@AbirAbbas AbirAbbas left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Merging this. Thanks for the disclosure up front, for following the existing attribution/config helpers instead of inventing a second shape, and for the live end-to-end runs — that made this much easier to check. Sorry it sat: the required checks never appeared because the workflow runs were stuck behind our first-time-contributor approval gate, and the advice you got to rebase wouldn't have fixed it. That was our mistake.

I pushed one commit before merging. DefaultConfig() applied the Infron block unconditionally, so an environment with OPENAI_API_KEY already set plus INFRON_API_KEY resolved to the Infron key and llm.onerouter.pro — which contradicts the guarantee in your own doc comment and in ENVIRONMENT_VARIABLES.md that adding an Infron key never reroutes an existing deployment. I proved it with a throwaway test before changing anything: APIKey="infron-key" BaseURL="https://llm.onerouter.pro/v1".

Your TestDefaultConfigExistingGatewayWinsOverInfron sets OPENAI_API_KEY to empty on its first line, so it only exercised the OpenRouter branch — which was already correct — and the OpenAI case slipped through green. The fix is an apiKey == "" guard on the Infron branch, plus regression tests for both the OpenAI-wins case and the Infron-still-applies-when-alone case.

I read this as an ordering oversight rather than intent — you clearly built the OpenRouter-wins ordering deliberately and OpenAI just needed the same guard. It mattered enough to fix rather than reword because agent processes inherit the parent environment, so a single exported INFRON_API_KEY would have moved every Go agent's traffic and credential. If you actually intended Infron to take precedence over a direct OpenAI key, say so and we'll reword the docs instead.

Two smaller things in the same commit: a comment on defaultInfronBaseURL noting onerouter.pro is Infron's gateway domain, since nothing in the repo connected the two names and grepping either one dead-ended; and ENVIRONMENT_VARIABLES.md now names the OpenRouter fallback vars explicitly rather than saying "the attribution values documented above", so an operator auditing what feeds the gateway can actually grep for them.

Three other things came up in review and I want to record that they were checked and cleared, so they don't get re-litigated later. normalizeNativeCost being called outside the gateway switch is fine — it early-returns on Cost == nil and no shipped provider sends top-level cost. The attribution fallback to the OpenRouter vars is fine — it's opt-in behind INFRON_API_KEY, has a kill switch, and carries values the README tells operators to set to their public product name. And IsInfron's strings.Contains is character-for-character what IsOpenRouter has always done; tightening both to host matching is our cleanup, not yours.

@AbirAbbas
AbirAbbas added this pull request to the merge queue Aug 5, 2026
Merged via the queue into Agent-Field:main with commit 64d27aa Aug 5, 2026
19 checks passed
AbirAbbas added a commit that referenced this pull request Aug 5, 2026
… (#884)

* fix(sdk/go/ai): never fabricate zero-token usage from a top-level cost

normalizeNativeCost synthesized an empty Usage{} when a body carried a
top-level cost without a usage block. On the streaming path every consumer
accumulates usage last-non-nil-wins, so a cost-only chunk arriving after
the real usage chunk replaced genuine token counts with zeros — recorded
downstream as input=0/output=0 with cost_source "provider", an
authoritative-looking row that has lost its tokens. Fold the cost only
into a usage block the provider actually sent.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(sdk/go/ai): don't inherit attribution values past their opt-out

Infron attribution fell back to the OpenRouter-scoped site URL and app
name but never consulted AGENTFIELD_OPENROUTER_ATTRIBUTION, so values a
deployment had explicitly suppressed — often internal hostnames or
product names — were sent to a different vendor on the first Infron
call. Inherit the values only while OpenRouter attribution is enabled;
the Infron defaults apply otherwise.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants